Skip to content

Distinguish expired, refresh-failed, and absent auth sessions - #160

Open
jeff-gusto wants to merge 14 commits into
mainfrom
js/aint-830-auth-error-taxonomy
Open

Distinguish expired, refresh-failed, and absent auth sessions#160
jeff-gusto wants to merge 14 commits into
mainfrom
js/aint-830-auth-error-taxonomy

Conversation

@jeff-gusto

@jeff-gusto jeff-gusto commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Summary

  • An expired access token with a valid refresh token on file was reported as no_access_token — "run gusto auth login". Logging in mints a new pair and overwrites the stored refresh token, so an agent that followed that instruction destroyed the state that would have recovered, and each retry left the install worse than the last. Split into three codes that call for different actions: no_access_token (nothing on file), session_expired (expired, can't be renewed locally), token_refresh_failed (refresh attempted and rejected, refresh token still on file). All keep exit code 3.
  • token_refresh_failed's recovery depends on why the refresh was rejected. Where the server turned down the attempt (a 5xx, temporarily_unavailable), the refresh token is still good and the message says to retry first — the retry is free, and auth login is not: it needs a human at a browser an agent on a headless box can't produce, and a successful one invalidates the refresh token it replaces. Where the server turned down the token (invalid_grant — per RFC 6749 an invalid, expired, or revoked grant, and the reason a dead refresh token actually comes back with), a retry re-runs the same refresh and fails identically, so the message points at auth login instead and notes that the credential the login replaces is already dead. Same code either way: what differs is the recovery, not the state a caller branches on.
  • A 401 from the API had the same problem one layer out: it fell through to the generic 4xx bucket as api_client_error at exit 4, the same envelope a malformed request gets, with no environment and no guidance. It's now credential_rejected at exit 3, joining the 403 insufficient_scope case that has always classified this way. The message names which credential was refused, because the recovery differs — a stored session can be signed in again, while a token from GUSTO_ACCESS_TOKEN or --token-stdin is the caller's to fix and must not be answered with auth login, which would rotate a session the failing command never used. Nothing re-authenticates a rejected token on its own, so nothing here suggests a bare retry; that's the opposite of token_refresh_failed, where the retry is sometimes the point. For a stored session the message also names what the login costs: a 401 doesn't prove the slot's refresh token is bad, and the login replaces it.
  • Made the active environment visible, since it wasn't available anywhere it mattered: auth whoami now returns environment, auth errors carry it in a typed environment field and name the credential slot they read, and the auth subcommands document --env and the per-environment credential slots. When the requested environment has no usable session but the other does, the error hints at it.
  • Wired up config.toml's environment key, which was validated and persisted and then read by nothing — so the recovery that hint recommends actually works. Precedence: --env > GUSTO_ENVIRONMENT > config > production, resolved by commander itself (--env outranks .env(), which outranks .default()), so no new state carries it.

Breaking: a 401 now exits 3 where it used to exit 4. Anything branching on 4 to mean "bad request" stops seeing 401s there; anything branching on 3 for credential trouble starts catching them, which is the point. Two existing tests asserted the old classification (the MCP gateway's 401 → api_client_error, and whoami's token_info error) and now assert the new one — flagging that explicitly, since it's the evidence this is a contract change rather than an addition. README.md documented 4 for all API 4xx, which was already untrue of the 403 scope case; it now says what actually decides it.

Behavior change worth flagging: an expired token with no refresh token used to be sent to the API anyway and 401 back. It's now reported as session_expired without a request. One existing test asserted the old collapsed behavior and has been rewritten to cover the real refresh-failure path (it had been simulating a refresh failure by making store.load() throw, which never happens with a real store).

Implementation notes.

  • The credential is stamped onto the ApiError the client throws, the way requestId already is, rather than threaded through call sites as a parameter. Every toResult caller then reports a 401 identically — including gusto api, report, ledger, payroll and the MCP surface, which sit several frames from a resolved context — and toResult's signature is unchanged. requestId and auth ride in one optional context object, since four required positional args are already the limit of what reads unlabeled at a call site.
  • The environment default is the --env option's .default(), installed once in buildProgram from the config read in main(). Commander then resolves the whole precedence chain, so readGlobalFlags just passes the value through and nothing new is cached. The default is only installed when one is actually persisted, so an unset --env stays undefined and defaultEnv keeps sole ownership of the production fallback.
  • resolveSessionToken returns the discriminated SessionOutcome and is what commands resolve through. getValidUserToken used to sit on top of it flattening three failure kinds into null-or-throw; with resolveSessionToken in place that adapter only existed to be tested, so it's inlined into withUserToken, its one caller.

Linked issue

AINT-830

Follow-ups filed rather than folded in:

  • Refreshing a rejected credential and retrying the request. withUserToken already implements reactive refresh-on-401 and still has no production caller. Deliberately left out of this PR: it only helps where the credential is recoverable (clock skew, an absent expiresAt, a long command crossing expiry), while a server-side revocation fails either way — whereas correct classification helps every case. Wiring it in also isn't a hookup: withUserToken replays a whole operation, so a late 401 in a paginated walk or a poll() would replay the entire command, writes included. It belongs per-request inside ApiClient, which means the token can no longer be baked in at construction. Until it exists, credential_rejected on a stored session can't offer anything cheaper than a login, and says so.
  • config set format is dead the same way environment was.

Test plan

  • bun run test:all passes locally — 1370 pass, 1 skip (pre-existing), 0 fail
  • Manual run of touched commands works against sandbox
  • --agent and --human output verified where touched

Manual verification ran the compiled binary against a synthetic credentials file under a temp XDG_CONFIG_HOME, with OAuth traffic pointed at a local stub, so no real refresh token was rotated:

  • expired slot, no refresh token → session_expired, dated, names the slot
  • expired slot with a refresh token, stub returns 503 temporarily_unavailabletoken_refresh_failed, retry-first wording, and the stored refresh token still on file afterward — the actual regression
  • same slot, stub returns 400 invalid_granttoken_refresh_failed, login wording naming the token as already dead, no retry advice, refresh token still on file
  • server's reason lifted into the message in both cases, body in details
  • both carry a hint naming the other slot
  • config set environment sandbox then a bare command resolves sandbox; GUSTO_ENVIRONMENT overrides it; --env overrides both; no config resolves production; a corrupt config warns on stderr and falls back rather than aborting

For the 401 path, a local stub returning 401 with a request id, hit through the compiled binary:

  • GUSTO_ACCESS_TOKENcredential_rejected, exit 3, names the env var, environment and request_id populated, no auth login suggested
  • --token-stdin → same, naming that flag instead
  • both readable in --human mode, where the envelope's environment field isn't printed and the message has to carry it

The same expired-production-plus-valid-sandbox state is covered as a regression test at both the unit level and through the compiled binary.

DCO

  • Every commit is signed off (git commit -s) per the DCO

🤖 Generated with Claude Code

jeff-gusto and others added 13 commits August 4, 2026 15:45
An expired access token with a valid refresh token on file was reported as
`no_access_token` - "run `gusto auth login`". Logging in mints a new pair and
overwrites the stored refresh token, so an agent following that instruction
destroyed the state that would have recovered, and every retry left the install
worse off than the last.

Split the one code into three that call for different actions:

- `no_access_token` - nothing on file. Log in.
- `session_expired` - expired with no way to renew it locally. Log in.
- `token_refresh_failed` - a refresh was attempted and rejected while the
  refresh token is still on file. Retry the command; only log in if that fails
  too.

All three keep exit code 3, and each names the environment it looked in and the
credential slot it read. `token_refresh_failed` lifts the token endpoint's own
reason into the message rather than leaving a bare status line.

Make the environment visible while we're at it, since it was unavailable
anywhere it mattered. `auth whoami` reports it, auth errors carry it in a typed
`environment` field, and the three `auth` subcommands document `--env`, its
production default, and the fact that credentials are stored per environment.
When the requested environment has no usable session but the other one does, the
error hints at it - a success under `--env sandbox` followed by a wall in
production reads as a broken credential model until something connects the two.

Wire up `config.toml`'s `environment` key, which was validated and persisted and
then read by nothing, so the recovery that hint recommends actually works. It
sits at the bottom of the precedence chain: `--env` > `GUSTO_ENVIRONMENT` >
config > production. A corrupt config file warns and is ignored rather than
aborting, since failing pre-parse would also block `gusto config reset`.

Behavior change worth naming: an expired token with no refresh token used to be
sent to the API anyway and 401 back. It is now reported as `session_expired`
without a request.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Jeff Stephens <jeff.stephens@gusto.com>
This repo is public, so a ticket key in a comment is a dead reference for any
reader who can't resolve it. Each of these drops the parenthetical and keeps the
sentence around it - the reasoning was the useful part, not the pointer.

One needed rewording rather than deletion, since the key was carrying the
sentence's subject.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Jeff Stephens <jeff.stephens@gusto.com>
Several comments explained the code by narrating the incident that prompted it -
"the bug this ticket exists for", "previously absent", "the old single
no_access_token", "a machine sat in this exact shape for over a week". That
framing decays: a reader a year out has no ticket, no before-state, and no
memory of whose machine it was, and is left with a comment that describes
something no longer in the tree.

Each now states the constraint as it stands. Same reasoning, anchored to the
code instead of to a moment: why the three codes can't be interchanged, why the
refresh token is left in place, why whoami has to report the environment, and
what state the regression tests pin.

Comments and one test name only; no behavior change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Jeff Stephens <jeff.stephens@gusto.com>
Three comments claimed that re-running `auth login` after a refresh failure
turns a recoverable state into an unrecoverable one, and that each retry leaves
the install worse than the last. Neither is true of this code.

`login` calls `store.save` only after the code exchange and the token_info
lookup both succeed, and its catch just flushes a failure page and rethrows. So
a login either replaces the slot with a working token pair or writes nothing at
all - the prior refresh token survives an abandoned or failed attempt. Nothing
accumulates across attempts either: client creds are reused rather than
re-registered, and there is no partial write to compound.

The rule those comments guard is still right, for a different reason. A retry
after `refresh_failed` is free and the credential it needs is still on file,
while a login needs a human at a browser - which is precisely what an agent on a
headless box cannot produce, so pointing there dead-ends instead of recovering.
A successful login does invalidate the refresh token it replaces, which matters
to anything else holding that credential; it does not harm this install.

Pin the invariant the retry advice depends on: a failed login must leave an
existing session's tokens intact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Jeff Stephens <jeff.stephens@gusto.com>
Four review fixes, all in the taxonomy this branch introduces:

- getValidUserToken's doc comment claimed it returns null whenever the session
  can't produce a token, but it throws on a rejected refresh. A caller reduced to
  null can't tell that state from absence, which is the confusion the branch
  exists to remove.
- whoami's comment claimed to be the only place the environment is reported;
  failures now carry it in error.environment too. Scoped to success.
- sessionFailure's comment named `refresh_failed` alongside two real wire codes,
  but that one is the internal outcome kind - the wire code is
  token_refresh_failed.
- no_company_uuid set the environment field while its message never named it.
  Human-mode output prints the message and never that field, so the environment
  was invisible to half the callers for an error whose own comment calls it
  decisive.

Signed-off-by: Jeff Stephens <jeff.stephens@gusto.com>
A 401 fell through to the generic 4xx bucket: code api_client_error, exit 4,
message just the request line. Same code a 422 gets, so nothing distinguished
"your credential is no good" from "your request is malformed", and none of the
auth taxonomy applied - no environment, no guidance, and an exit code that a
caller branching on 3 for credential trouble never sees.

It now exits Auth as credential_rejected, alongside the 403 insufficient_scope
case that has always classified this way. The message names which credential was
refused, because the recovery differs: a stored session can be signed in again,
while a token supplied through GUSTO_ACCESS_TOKEN or --token-stdin is the
caller's to fix and must not be answered with `auth login`, which would rotate a
session the failing command never used. Nothing re-authenticates a rejected token
on its own, so no wording suggests a bare retry - the opposite of
token_refresh_failed, where the retry is the whole point.

The client stamps its credential onto the ApiError it throws, the way it already
does for requestId, so every toResult caller reports a 401 identically without
threading context through call sites several frames from a resolved context.

Behavior change: a 401 exits 3 where it used to exit 4. Two tests asserted the
old classification and now assert the new one. README's exit-code line said 4 for
all API 4xx, which was already untrue of the 403 scope case; it now says what
decides it.

Not included: refreshing a rejected token and retrying the request. It only helps
where the credential is recoverable, and a server-side revocation still fails -
whereas correct classification helps every case. The reactive refresh path in
withUserToken remains unwired.

Signed-off-by: Jeff Stephens <jeff.stephens@gusto.com>
The within-skew failure path said "the 401 path refreshes later if needed",
deferring to `withUserToken`. Nothing reaches that function, so the deferral
described a recovery that never happens: the token goes out, comes back 401, and
is reported rather than refreshed. Say that, and name where the 401 lands.

The neighboring short-circuit justified itself with "a 401 whose message says
nothing about why", which classifying 401s made untrue. The reason to name the
state locally is now that we already know it, and only the local state can date
the expiry and name the slot - not that the alternative is uninformative.

Signed-off-by: Jeff Stephens <jeff.stephens@gusto.com>
Six comments explained the change being made rather than the code being left
behind, which reads fine in review and badly a year later:

- The within-skew passthrough cited the absence of a caller for withUserToken.
  That is a fact about today's call graph, and wiring one up would make the
  comment wrong while the behavior it describes stayed the same. Says there is no
  reactive refresh, and what that costs.
- The expiry short-circuit compared itself to how a 401 used to read. Compares
  the two reports on what each can say instead.
- The credential on ApiError, its client option, and the type itself justified
  where they live against the alternative of threading a parameter. Nobody
  maintaining this needs the road not taken; they need to know the credential
  rides on the error so distant callers can name it.
- The end-to-end 401 test defended its own existence against the unit test.
  Now says what it guards: a hand-built ApiError satisfies toResult whether or
  not the wiring that fills it in still holds.

Signed-off-by: Jeff Stephens <jeff.stephens@gusto.com>
The persisted `environment` was installed through module state in
global-flags.ts and a `setConfiguredEnvironment` setter called from
main(), so that a synchronous `readGlobalFlags` could reach it. Commander
already resolves this: an explicit `--env` outranks `.env("GUSTO_ENVIRONMENT")`,
which outranks `.default()`. Read the config before building the program
and pass it in as that default.

Drops the mutable module state, the test-only setter, and the per-test
restore discipline it needed. `readGlobalFlags` goes back to passing the
resolved value through, and `defaultEnv` still owns the production
fallback - the default is only installed when one is actually persisted,
so an unset `--env` stays undefined.

The precedence chain was already covered end to end through the compiled
binary in tests/smoke.test.ts, which is where it belongs now that
commander owns it; the unit tests of the setter go away.

Signed-off-by: Jeff Stephens <jeff.stephens@gusto.com>
Stamping `auth` onto the error made the constructor six positional
params, two of them optional trailers that read identically at a call
site. Group `requestId` and `auth` into a context object; the four
required args stay positional.

Signed-off-by: Jeff Stephens <jeff.stephens@gusto.com>
`token_refresh_failed` told every caller to "retry the command first,
since the retry is free". That holds when the server rejected the
attempt, but `invalid_grant` - the reason a revoked or expired refresh
token actually comes back with, and the common case - is a verdict on
the token, so the retry re-runs the same refresh and fails identically.
The advice was wrong for the state it fires on most.

Split the message on the reason: `invalid_grant` names the token as
rejected and points at `auth login`, noting that the credential the
login replaces is already dead, so replacing it costs nothing. Every
other reason (a 5xx, `temporarily_unavailable`, a fetch fault) keeps
the retry-first wording it was written for. The code is unchanged -
what differs is the recovery, not the state a caller branches on.

`credential_rejected` had a quieter version of the same problem: for a
stored session it sent the caller to `auth login` without saying that
this replaces the slot's refresh token, which a 401 does not prove is
bad - notably in a slot with no recorded `expiresAt`, where nothing
refreshed proactively because nothing knew to. It still points there,
since a rerun re-sends the same rejected token and refreshing a
rejected credential in place is `withUserToken`'s unwired job, but it
now says what the login spends.

Verified against a local token-endpoint stub: `invalid_grant` gets the
login wording, `temporarily_unavailable` gets the retry wording, and
the stored refresh token survives both.

Signed-off-by: Jeff Stephens <jeff.stephens@gusto.com>
After `resolveSessionToken` landed, `getValidUserToken` was an adapter
that flattened three failure kinds into null-or-throw, and its own doc
comment pointed callers at `resolveSessionToken` instead. It has no
production callers - neither does `withUserToken`, which is parked for
reactive refresh - so the adapter existed only to be tested. Inline it.

Its one test the union's own tests didn't already cover (a successful
within-skew refresh persisting the new pair) moves to the
`resolveSessionToken` block.

Also states the login-is-expensive invariant once, on the union that
encodes it, rather than re-deriving it at each site that reports one of
these states; `refresh_failed` now records that whether its refresh
token is still usable depends on `cause`.

Signed-off-by: Jeff Stephens <jeff.stephens@gusto.com>
Conflict was the api-context.ts import block only. Resolved as the union
of both sides, minus `getValidUserToken`, which this branch inlined into
`withUserToken` and deleted. `OAuthError` goes back to a type-only
import: main needed it as a value for an `instanceof` check in the old
`sessionToken`, which `resolveSessionToken` now owns.

main's new `putResourceWithVersion` resolves through `resolveApiContext`,
so it picks up the auth-context stamping and reports a 401 as
`credential_rejected` with no extra wiring. Its two clarification legs
compose with that: `clarifyVersionConflict` gates on `ExitCode.ApiClient`
so an auth failure passes through untouched, and
`clarifyVersionReadFailure` spreads the underlying error, keeping the
code, exit, and `environment` while explaining nothing was written.

Signed-off-by: Jeff Stephens <jeff.stephens@gusto.com>
@jeff-gusto
jeff-gusto marked this pull request as ready for review August 10, 2026 16:02
@jeff-gusto
jeff-gusto requested review from a team and ashieh as code owners August 10, 2026 16:02
Comment thread src/lib/api-context.ts Outdated
try {
const store = opts.store ?? resolveStore();
const session = await store.load(other);
if (!session?.accessToken) return undefined;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

store.load() returns the raw TOML object without interpreting expiresAt, so session.accessToken can be truthy on an expired slot.

otherEnvHint only checks for a present token, so it can hint at an environment that would also fail.

I think the most maintainable fix is probably to call resolveSessionToken on the other slot and only hint when it comes back ok (that keeps all the validity logic consolidated). The wrinkle is that resolveSessionToken can trigger a proactive refresh, so it may be worth a small refactor there to support a read-only path.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch - a stale slot did read as usable, and the hint pointed straight at a second wall. Fixed in ae8480f, taking the route you suggested.

resolveSessionToken is now split: classifySession(session, now) is the file-only verdict, and resolveSessionToken is that plus the refresh attempt. The state that needed carving out is refreshable - at or near expiry with a refresh token and client creds - since only a request can settle it. SessionState is SessionOutcome minus refresh_failed plus that one, so the two can't drift.

sessionUsable(store, env, now) is the read-only path: it classifies and answers true for ok or refreshable, without touching the network. otherEnvHint gates on it now instead of on a truthy access token. refreshable counts as usable deliberately - --env sandbox renews it on the way through, so an expired-but-renewable slot is still the right thing to point at.

Two smoke tests were asserting the old hint: their fixture had both slots expired with no refresh token, which is precisely the state that shouldn't produce one. writeCredentials now takes per-slot expiries, so the both-expired case asserts no hint and two new fixtures cover the hint in each direction. Still no network calls in that describe - moving one slot's expiry forward only changes the other slot's error.

`otherEnvHint` read the other slot's raw TOML and treated a truthy access
token as a usable session. A slot carries whatever the file says, so a token
that expired weeks ago reads as present and the hint sent the caller from one
wall to the next.

Split the file-only verdict out of `resolveSessionToken` as `classifySession`,
with a `refreshable` state for the near-expiry-with-refresh-token case that
only a request can resolve. `resolveSessionToken` acts on it; `sessionUsable`
reads the same verdict without touching the network, so the hint can't rotate
a credential nobody asked us to touch.

Signed-off-by: Jeff Stephens <jeff.stephens@gusto.com>
@jeff-gusto
jeff-gusto requested a review from DamoneMX August 11, 2026 22:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants